Analyzers are wonderfully testable. You give the framework a string of C#, it compiles it in memory, runs your analyzer, and checks that the diagnostics you expected — and only those — were produced. No files, no projects, no I/O.
In this part we test the PSC001 rule from Writing your first diagnostic. The full source code of the analyzer is available on GitHub.
The test project
Create a test project that targets your app’s framework (here net10.0) — the
in-memory compilation needs a modern runtime and the Blazor assemblies:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<!-- Pin Roslyn to the analyzer's version; the Workspaces reference silences
the ancient transitive package the test framework pulls in (NU1701). -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.11.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\PSC.Analyzers.Blazor\PSC.Analyzers.Blazor.csproj" />
</ItemGroup>
</Project>
Note the ProjectReference is a normal reference (no OutputItemType="Analyzer")
— we want the analyzer’s types so we can instantiate them in tests.
Reference assemblies: the one tricky bit
The framework builds its own compilation, so we must tell it which assemblies that
compilation references. Our test source uses ComponentBase and
[Parameter], so the reference set needs the Blazor assemblies. We build a custom
ReferenceAssemblies once and reuse it:
using System.Collections.Immutable;
using System.IO;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;
namespace PSC.Analyzers.Blazor.Tests;
internal static class BlazorReferences
{
public static readonly ReferenceAssemblies Net10 = new ReferenceAssemblies(
"net10.0",
new PackageIdentity("Microsoft.NETCore.App.Ref", "10.0.5"),
Path.Combine("ref", "net10.0"))
.AddPackages(ImmutableArray.Create(
new PackageIdentity("Microsoft.AspNetCore.Components", "10.0.8")));
}
internal static class Verify<TAnalyzer>
where TAnalyzer : DiagnosticAnalyzer, new()
{
public static Task ForSource(string source)
{
var test = new CSharpAnalyzerTest<TAnalyzer, DefaultVerifier>
{
TestCode = source,
ReferenceAssemblies = BlazorReferences.Net10,
};
return test.RunAsync();
}
}
AddPackages pulls Microsoft.AspNetCore.Components (and its dependencies) from
NuGet into the reference set, so ComponentBase and ParameterAttribute resolve.
The Verify<TAnalyzer> helper trims the boilerplate down to a single call.
Writing tests with markup
Expected diagnostics are declared inline in the source using {|ID:span|}
markup. {|PSC001:policyId|} says: “I expect a PSC001 diagnostic whose squiggle
covers exactly policyId.” The markup is stripped before compilation.
using System.Threading.Tasks;
using Xunit;
namespace PSC.Analyzers.Blazor.Tests;
public class ParameterNamingAnalyzerTests
{
[Fact]
public async Task Flags_camelCase_parameter()
{
const string source = """
using Microsoft.AspNetCore.Components;
public class Sample : ComponentBase
{
[Parameter] public int {|PSC001:policyId|} { get; set; }
}
""";
await Verify<ParameterNamingAnalyzer>.ForSource(source);
}
[Fact]
public async Task Ignores_PascalCase_parameter()
{
const string source = """
using Microsoft.AspNetCore.Components;
public class Sample : ComponentBase
{
[Parameter] public int PolicyId { get; set; }
}
""";
await Verify<ParameterNamingAnalyzer>.ForSource(source);
}
[Fact]
public async Task Ignores_property_without_Parameter_attribute()
{
const string source = """
using Microsoft.AspNetCore.Components;
public class Sample : ComponentBase
{
public int policyId { get; set; }
}
""";
await Verify<ParameterNamingAnalyzer>.ForSource(source);
}
}
Positive and negative cases
The two kinds of test matter equally:
- The positive test (
Flags_camelCase_parameter) proves the rule fires — with markup declaring the expected diagnostic. - The negative tests have no markup, which asserts the analyzer produces
nothing. These guard against false positives — the fastest way to make a rule
hated is to flag correct code. Here we prove a PascalCase parameter and a plain
(non-
[Parameter]) property are both left alone.
RunAsync() fails the test if there’s any mismatch: a missing expected diagnostic,
an unexpected one, or one on the wrong span. It also fails on unexpected compiler
errors, which is a handy signal that your reference assemblies are wrong — if
ComponentBase didn’t resolve, you’d see a CS0246 and know immediately.
Run them
dotnet test
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3
Three tests, three behaviours pinned down. As you add rules — a “use EventCallback,
not Action” rule, say — you add a test class per analyzer and follow the same
positive/negative pattern.
Tips
- Assert the message text too when it carries information. Markup checks the id
and location; to also check the message, build a
DiagnosticResultexplicitly with.WithSpan(...).WithArguments(...)instead of using markup. - Keep sources minimal. The smallest snippet that triggers the rule is the clearest test and the fastest to compile.
- One analyzer per
Verify<T>. Only the analyzer under test runs, so rules never interfere with each other’s tests.
We now have a rule that’s proven to behave. In Part 4 we turn the project into a proper NuGet package — placed correctly so the compiler loads it — and consume it from another project.